Skip to content

fix(desktop): decide mention eligibility for external agents from relay proofs - #3676

Open
Shipitrealgood wants to merge 2 commits into
block:mainfrom
Shipitrealgood:fix/mention-eligibility-external-agents
Open

fix(desktop): decide mention eligibility for external agents from relay proofs#3676
Shipitrealgood wants to merge 2 commits into
block:mainfrom
Shipitrealgood:fix/mention-eligibility-external-agents

Conversation

@Shipitrealgood

@Shipitrealgood Shipitrealgood commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Owner-attested external agents cannot be @-mentioned. The mention
candidate gate (added in #2149) drops every agent-classified identity
that is not in the local managed store — before the directory
eligibility logic (relayAgentIsSharedWithUser / the directory branch of
shouldHideAgentFromMentions) can run, so that shipped logic is
unreachable for them. The sharp edge from #2987, which we reproduced
independently: an external agent is mentionable while unattested
(misclassified as a person), and registering it correctly — kind:0
with verified NIP-OA attestation plus a kind:10100 directory entry — is
what makes it unreachable.

This PR rewrites the gate as one predicate that decides "will this agent
receive the message, and does its policy admit responding to you?" from
the same relay facts other agent surfaces already trust, keeping the
local store as one proof among several rather than the gatekeeper:

mentionable(agent, channel, viewer) =
     locallyManaged(agent)                         // native path, unchanged — membership
                                                   // NOT required (covers the managed
                                                   // non-member auto-add flow)
  || ( member(agent, channel)                      // external agents: relay membership,
                                                   // then the agent's own kind:10100
                                                   // declaration:
       && (  respond_to == "anyone"
          || (respond_to == "allowlist" && viewer ∈ allowlist)
          || (respond_to == "owner-only"
              && verifiedOwner(agent) == viewer) ) )   // the NIP-OA-verified ownerPubkey
                                                       // the "managed by" header renders

Deliberate boundaries:

owner-only external agents becoming mentionable by their verified
owner
is new capability the pre-aggregated sets could not express — it
composes the ownership proof the client already verifies for the
"managed by" surface with the agent's own policy declaration.

What this deliberately does not fix (same family, follow-ups)

Related issue

Addresses the @-mention core of #2987 (kept open deliberately: that
report also covers the add-member search — first follow-up below — and
its comment thread documents declaration-less subcases discussed there).
Supplies the mechanism for #3125 (and the cross-machine
flavors #2508 / #2349 / #3277 / #3204) — see the section above for what
remains. Closest existing PRs, oldest first: #3110 (gate-loosening in both
the mention and add-member surfaces), #3448 (same root plus the
add-member search and a fetch-merge-write set-add-policy; its CLI
piece is exactly right and complementary to this change), and #3609
(minimal same-root fix admitting the precomputed mentionable set at the
gate). Relative to both, this PR keeps agents without any directory
declaration hidden (preserving #2149's stale-identity suppression, which
removing or bypassing the pre-gate alone re-opens through the
member-not-in-directory branch), requires channel membership, and
handles owner-only-by-owner via the verified attestation. Happy to
coordinate however the maintainers prefer — these three PRs are
convergent, not competing.

Testing

  • cd desktop && pnpm test — 3,784 passing
  • pnpm exec tsc --noEmit — clean
  • pnpm exec biome check src/features/agents src/features/messages — clean
  • Unit tests cover the policy matrix: anyone / allowlist in + out /
    owner-only as owner, as non-owner, unverified / no declaration /
    non-member external / managed member + non-member / unknown viewer /
    pubkey normalization on every comparison.
  • Live verification on a self-hosted relay with two headless
    buzz-acp agents (systemd on a Linux host, NIP-OA-attested, full
    kind:10100 entries — the External (non-desktop-managed) agents: publishing kind:10100 makes them un-mentionable — shouldHideAgentFromMentions's invocability logic is unreachable #2987 topology): before this change the
    agents render agent chips and verified "managed by" but never appear
    in @-autocomplete; with it, autocomplete offers them, the published
    event carries the agent p tag, and the harness responds. Stale
    registration facts were confirmed on-relay via the NIP-98 bridge while
    root-causing.

Owner-attested external agents (kind:0 with verified NIP-OA attestation +
kind:10100 directory entry) could not be @-mentioned: the candidate gate
dropped every agent-classified identity not present in the local managed
store, before the directory eligibility logic could run — making that
shipped logic unreachable. Externally-run agents were mentionable only
while UNattested (misclassified as people); registering them correctly
made them unreachable.

Rewrite the mention gate as one predicate deciding "will this agent
receive the message and does its policy admit responding to you", from
the same proofs other agent surfaces already trust:

- a local managed record (the native path — unchanged, including the
  non-member auto-add flow), or
- channel membership (candidates originate from the member list) plus the
  agent's own kind:10100 respond-to declaration: `anyone`, an allowlist
  naming the viewer, or `owner-only` when the viewer IS the agent's
  NIP-OA-verified owner (the same verified ownerPubkey the "managed by"
  header renders).

Liveness is deliberately not part of the predicate: buzz-acp replays
missed mentions on reconnect, and stopped managed agents are offered
today. Agent-classified candidates with no local record and no directory
declaration stay hidden, preserving the no-void-mentions guarantee the
old gate encoded. Offering non-member external agents (auto-add honoring
the directory's channel_add_policy) is an explicit follow-up.

Replaces the stacked isAgentIdentityInManagedList + directory-set checks
in useMentions with the single predicate; isAgentIdentityInManagedList
remains for its members-sidebar caller. Unit tests cover the full policy
matrix (anyone / allowlist in+out / owner-only as owner, non-owner,
unverified / no declaration / non-member / managed member+non-member /
unknown viewer / pubkey normalization).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryce Del Rio <bryce.delrio@gmail.com>
@Shipitrealgood
Shipitrealgood requested a review from a team as a code owner July 30, 2026 03:43
Shipitrealgood added a commit to Shipitrealgood/buzz that referenced this pull request Jul 30, 2026
…licy

The add-member search dropped every agent-classified candidate not in
the local managed store, and the relay's actual authority for
third-party adds — the agent's kind:10100 channel_add_policy, enforced
server-side in handlers/side_effects.rs (owner_only checked against the
stored attested owner, nobody refused, anyone allowed) — was never
consulted client-side: the field was not even carried through the
RelayAgentInfo conversion.

Carry channel_add_policy through the wire types and gate the picker on a
predicate aligned with relay enforcement: managed agents always offered
(native parity, unchanged); external agents offered when their declared
policy is "anyone", or "owner_only" when the viewer is the
NIP-OA-verified owner; "nobody" hidden from everyone including the owner
(the relay refuses those adds regardless of actor).

The picker never offers an external-agent add the relay will reject. In
one direction it is deliberately STRICTER than the relay: agents with no
directory entry stay hidden even though the relay — whose stored policy
defaults to 'anyone' — would accept the add. That tightening preserves
the stale-identity suppression the managed-list gate was introduced for
(block#2149): no declaration, no offer.

Complements the mention-eligibility fix (block#3676): each surface reads its
own declaration — respond_to for mentions, channel_add_policy for adds.

Unit tests cover the matrix: people / managed / anyone / owner_only as
owner, non-owner, unverified / nobody incl. owner / no entry / null
policy / pubkey normalization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryce Del Rio <bryce.delrio@gmail.com>
…d owner

Community feedback on block#2987 surfaced two subcases the predicate left
cold: an attested external agent with no kind:10100 at all (never
published, or deleted), and — after the block#2508 thread's cross-machine
reports — the agent's own owner on a device that doesn't manage it
(block#2349, block#3277). Both reduce to the same case: a member agent with a
verified NIP-OA attestation and no directory declaration.

Offer that candidate to its verified owner only. This is proof-sound:
the harness's inbound author gate admits the owner under every
respond-to mode, so an owner's mention is never a void chip — and it
needs no directory entry, which matters because nothing publishes
entries for native agents yet. Strangers still see nothing: attribution
without a declaration is not evidence the agent would answer them.

Stale same-name identities an owner might now see fold into the live
one via the existing same-label/same-owner coalescing, and NIP-IA
archived identities are peeled before candidates are built — the two
block#2149-era layers this change leans on, both untouched.

Tests: declaration-less agent hidden from strangers and unverified
attributions, offered to the verified owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryce Del Rio <bryce.delrio@gmail.com>
@Shipitrealgood

Copy link
Copy Markdown
Author

Scope note (post-#3178 / directory clarification)

This PR only changes mention eligibility over proofs the Desktop already loads: local managed set, kind:10100 policy, and kind:0 NIP-OA ownerPubkey.

It does not read kind:30177 (owner instance catalog — see #3178 for CLI owner-scoped lookup) and does not change mint/write of 10100. Native Desktop still may lack a full directory write; third-party mention of those agents remains limited until a writer exists. External agents with full 10100 + membership are the multi-client happy path this gate unlocks.

Ownership for the owner-only / no-declaration paths is the same NIP-OA class #3178 enforces on CLI; we consume Desktop’s existing ownerPubkey projection and do not re-verify conditions in TS.

@shawnhansen

Copy link
Copy Markdown

Independent validation from a live Hermes/Buzz setup:

  • Hermes publishes a NIP-OA-attested owner and a kind:10100 profile with respond_to: "owner-only".
  • Manually typed @Hermes routes correctly, but Desktop autocomplete drops the agent before policy eligibility because it is not in the local managed-agent list.
  • Requiring verified ownership and current channel membership restores autocomplete for the owner without changing gateway behavior.

Two fail-closed edge cases were important during independent review:

  1. An unrelated unmanaged channel-member agent with unknown policy must remain hidden.
  2. An owner-attested unmanaged agent that is not a channel member must remain hidden even if its pubkey is otherwise in the mentionable set.

I verified this PR's stated predicate covers both boundaries: external agents require channel membership plus an admissible relay policy, and owner-only requires the normalized NIP-OA-verified owner to match the viewer.

Local validation of the narrow reproduction/fix path passed the complete just ci gate, including desktop and mobile suites, plus a policy truth table covering normalized owner match, mismatch, missing/empty owner, unrelated unmanaged member, and owned non-member-but-mentionable.

This PR is the closest and most complete existing fix for the Hermes case; no additional duplicate PR opened.

@pdurlej

pdurlej commented Jul 30, 2026

Copy link
Copy Markdown

Independent focused verification of the relay-only mention path against Buzz Desktop v0.5.2:

  • full Desktop unit suite: 3,777 passed;
  • TypeScript typecheck, repository checks, and E2E build passed;
  • focused Playwright coverage: 2/2 passed.

The Playwright cases exercise the boundary this PR intends to preserve:

  1. an allowlisted relay-directory agent outside the current channel remains absent from mention autocomplete;
  2. the same relay-only agent becomes mentionable after joining the current channel;
  3. the sent event contains exactly one p tag for that agent;
  4. the start_managed_agent command count does not change when the message is sent.

That last assertion is worth retaining in the upstream regression test: eligibility for a relay-hosted agent must not imply local lifecycle ownership or start a duplicate harness.

This is supporting evidence for #3676, not a competing implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants